// Copyright 2012 Google Inc. All Rights Reserved.

package com.google.appengine.preferencessync.internal;

import com.google.appengine.preferencessync.internal.InternalSyncException.ExceptionType;
import com.google.appengine.syncserver.shared.PropertyChange;

import android.content.SharedPreferences;
import android.content.SharedPreferences.Editor;
import android.util.Log;

import org.codehaus.jackson.JsonGenerationException;
import org.codehaus.jackson.map.JsonMappingException;
import org.codehaus.jackson.map.ObjectMapper;

import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;

/**
 * Utility class for shared preferences.
 */
public class SharedPreferencesUtils {

  private static final String STRING_SET_NOT_FOUND_ERROR = "Set<String> supported by" +
      " SharedPreferences from Android 3.0";

  // Type of the value.
  private enum Type {
    BOOLEAN("B_", Boolean.class),
    INTEGER("I_", Integer.class),
    LONG("L_", Long.class),
    FLOAT("F_", Float.class),
    STRING("S_", String.class),
    STRINGSET("SS_", Set.class);

    // Finally all values are converted to strings.
    // This identifies the namespace for the value.
    private final String namespace;
    private final Class<?> clazz;

    private Type(String namespace, Class<?> clazz) {
      this.namespace = namespace;
      this.clazz = clazz;
    }

    public String getNamespace() {
      return namespace;
    }

    private Class<?> getClazz() {
      return clazz;
    }

    public static Type getType(Object o) {
      for (Type type : Type.values()) {
        if (type.getClazz().isInstance(o)) {
          return type;
        }
      }
      return null;
    }

    public static Type getType(String namespacedString) {
      for (Type type : Type.values()) {
        if (namespacedString.startsWith(type.getNamespace())) {
          return type;
        }
      }
      return null;
    }
  }

  /**
   * Copy key, value pairs from a Map to SharedPreferences.
   *
   * @param from
   * @param to
   */
  public static void copySharedPrefereneces(Map<String, ?> from, SharedPreferences to) {
    Editor editor = to.edit();
    for (Map.Entry<String, ?> entry : from.entrySet()) {
      putObjectIntoEditor(editor, entry.getKey(), entry.getValue());
    }
    editor.commit();
  }

  /**
   * There is no way to do editor.put(String, Object). Therefore, we have to
   * determine the type of the object before putting into the editor.
   *
   * @param editor
   * @param key
   * @param value
   */
  private static void putObjectIntoEditor(Editor editor, String key, Object value) {

    switch (Type.getType(value)) {
      case BOOLEAN:
        editor.putBoolean(key, ((Boolean) value).booleanValue());
        break;

      case INTEGER:
        editor.putInt(key, ((Integer) value).intValue());
        break;

      case LONG:
        editor.putLong(key, ((Long) value).longValue());
        break;

      case FLOAT:
        editor.putFloat(key, ((Float) value).floatValue());
        break;

      case STRING:
        editor.putString(key, (String) value);
        break;

      case STRINGSET:
        @SuppressWarnings("unchecked")
        Set<String> set = (Set<String>) value;
        try {
          editor.putStringSet(key, set);
        } catch (java.lang.NoSuchMethodError error) {
          SyncClientLog.logError(SharedPreferencesUtils.class, STRING_SET_NOT_FOUND_ERROR);
        }
    }
  }

  /**
   * Determines the type of the value by looking at its namespace and puts
   * String into the editor.
   *
   * @param editor
   * @param key
   * @param namespacedString
   */
  public static void putNamespacedStringAsObject(Editor editor, String key, String namespacedString)
      throws InternalSyncException {
    String nonNamespacedString = namespacedString.substring(
        Type.getType(namespacedString).getNamespace().length());

    switch (Type.getType(namespacedString)) {
      case BOOLEAN:
        editor.putBoolean(key, Boolean.valueOf(nonNamespacedString));
        break;

      case INTEGER:
        editor.putInt(key, Integer.valueOf(nonNamespacedString));
        break;

      case LONG:
        editor.putLong(key, Long.valueOf(nonNamespacedString));
        break;

      case FLOAT:
        editor.putFloat(key, Float.valueOf(nonNamespacedString));
        break;

      case STRING:
        editor.putString(key, nonNamespacedString);
        break;

      case STRINGSET:
        ObjectMapper mapper = new ObjectMapper();
        // TODO(chintanraoh): return object of type clazz.
        try {
          @SuppressWarnings("unchecked")
          ArrayList<String> list = mapper.readValue(
              nonNamespacedString, new ArrayList<String>().getClass());
          // LinkedHashSet maintains order.
          try {
            editor.putStringSet(key, new LinkedHashSet<String>(list));
          } catch (java.lang.NoSuchMethodError error) {
            SyncClientLog.logError(SharedPreferencesUtils.class, STRING_SET_NOT_FOUND_ERROR);
          }
        } catch (JsonGenerationException e) {
          throw new InternalSyncException(e, ExceptionType.INTERNAL_EXCEPTION,
              e.getMessage(), Log.ERROR);
        } catch (JsonMappingException e) {
          throw new InternalSyncException(e, ExceptionType.INTERNAL_EXCEPTION,
              e.getMessage(), Log.ERROR);
        } catch (IOException e) {
          throw new InternalSyncException(e, ExceptionType.INTERNAL_EXCEPTION,
              e.getMessage(), Log.ERROR);
        }
    }
  }

  /**
   * Determines the type of the value by looking at its namespace and puts
   * String into the editor.
   *
   * @param namespacedString
   */
  public static Object getNamespacedStringAsObject(String namespacedString)
      throws InternalSyncException {
    String nonNamespacedString = namespacedString.substring(
        Type.getType(namespacedString).getNamespace().length());

    switch (Type.getType(namespacedString)) {
      case BOOLEAN:
        return Boolean.valueOf(nonNamespacedString);

      case INTEGER:
        return Integer.valueOf(nonNamespacedString);

      case LONG:
        return Long.valueOf(nonNamespacedString);

      case FLOAT:
        return Float.valueOf(nonNamespacedString);

      case STRING:
        return nonNamespacedString;

      case STRINGSET:
        ObjectMapper mapper = new ObjectMapper();
        // TODO(chintanraoh): return object of type clazz.
        try {
          @SuppressWarnings("unchecked")
          ArrayList<String> list = mapper.readValue(
              nonNamespacedString, new ArrayList<String>().getClass());
          // LinkedHashSet maintains order.
          return new LinkedHashSet<String>(list);
        } catch (JsonGenerationException e) {
          throw new InternalSyncException(
              ExceptionType.INTERNAL_EXCEPTION, e.getMessage(), Log.ERROR);
        } catch (JsonMappingException e) {
          throw new InternalSyncException(
              ExceptionType.INTERNAL_EXCEPTION, e.getMessage(), Log.ERROR);
        } catch (IOException e) {
          throw new InternalSyncException(
              ExceptionType.INTERNAL_EXCEPTION, e.getMessage(), Log.ERROR);
        }
    }
    return null;
  }

  /**
   * Converts the object into a namespaced string and puts the namespaced string into the editor.
   *
   * @throws InternalSyncException
   */
  public static void putObjectAsNamespacedString(Editor editor, String key, Object value)
      throws InternalSyncException  {
    editor.putString(key, getStringValue(value));
  }

  /**
   * @param old
   * @param currentPreferences
   * @return change list which takes old preference map to the new preference map.
   * @throws InternalSyncException
   */
  public static ArrayList<PropertyChange> diffSharedPreferences(Map<String, String> old,
      SharedPreferences currentPreferences) throws InternalSyncException {

    ArrayList<PropertyChange> propertyChanges = new ArrayList<PropertyChange>();
    Map<String, ?> current  = currentPreferences.getAll();

    // Check whether an old key was deleted or updated.
    for (String key : old.keySet()) {
      if (!current.containsKey(key)) {
        // Value has been deleted
        SyncClientLog.logDebug(SharedPreferencesUtils.class, "New server key " + key);
        propertyChanges.add(new PropertyChange(key, null, true));
      } else {
        SyncClientLog.logDebug(SharedPreferencesUtils.class, "Comparing server key " + key);
        String newValue = getStringValue(current.get(key));
        SyncClientLog.logDebug(SharedPreferencesUtils.class,
            String.format("Old value %s New value %s", old.get(key), newValue));
        if (!newValue.equals(old.get(key))) {
          propertyChanges.add(new PropertyChange(key, newValue, false));
        }
      }
    }

    // Check whether any new key was added.
    for (String key : current.keySet()) {
      if (!old.containsKey(key)) {
        propertyChanges.add(new PropertyChange(key, getStringValue(current.get(key)), false));
      }
    }

    return propertyChanges;
  }

  /**
   * @param value
   * @return Get string version of the obect. The string will be namespaced with the depending on
   * the type of the object.
   * @throws InternalSyncException
   */
  @SuppressWarnings("cast")
  public static String getStringValue(Object value) throws InternalSyncException {
    Type type = Type.getType(value);
    String stringValue = "";
    switch (type) {
      case BOOLEAN:
        stringValue = String.valueOf((Boolean) value);
        break;

      case INTEGER:
        stringValue = String.valueOf((Integer) value);
        break;

      case LONG:
        stringValue = String.valueOf((Long) value);
        break;

      case FLOAT:
        stringValue = String.valueOf((Float) value);
        break;

      case STRING:
        stringValue = String.valueOf((String) value);
        break;

      case STRINGSET:
        @SuppressWarnings("unchecked")
        Set<String> set = (Set<String>) value;
        ObjectMapper mapper = new ObjectMapper();
        try {
          stringValue = mapper.writeValueAsString(new ArrayList<String>(set));
        } catch (JsonGenerationException e) {
          throw new InternalSyncException(
              ExceptionType.INTERNAL_EXCEPTION, e.getMessage(), Log.ERROR);
        } catch (JsonMappingException e) {
          throw new InternalSyncException(
              ExceptionType.INTERNAL_EXCEPTION, e.getMessage(), Log.ERROR);
        } catch (IOException e) {
          throw new InternalSyncException(
              ExceptionType.INTERNAL_EXCEPTION, e.getMessage(), Log.ERROR);
        }
    }

    return type.getNamespace() + stringValue;
  }
}
